D:\a\csshw\csshw\xtask\src\inject_agent_token.rs
Line | Count | Source |
1 | | //! Paseo agent GitHub auth injection. |
2 | | //! |
3 | | //! A paseo-spawned agent would otherwise inherit the user's full `gh` |
4 | | //! login - including classic scopes like `repo` that allow deleting |
5 | | //! repositories or force-pushing to `main`. This module is the |
6 | | //! counterpart of that risk: on worktree creation, it writes a |
7 | | //! per-worktree `.claude/settings.local.json` whose `env` map carries |
8 | | //! a fine-grained PAT supplied by the contributor. Claude Code |
9 | | //! injects that `env` into the agent process, and `gh` honors |
10 | | //! `GH_TOKEN` over the keyring, so the agent ends up acting as the |
11 | | //! scoped PAT while the contributor's own `gh` session outside paseo |
12 | | //! is unaffected. |
13 | | //! |
14 | | //! The token source is `<source-checkout>/.paseo/gh-token` - a |
15 | | //! gitignored file the contributor creates once per clone. The |
16 | | //! source checkout path is taken from the `PASEO_SOURCE_CHECKOUT_PATH` |
17 | | //! environment variable paseo sets when running setup steps; if that |
18 | | //! variable is absent, the current directory is used instead, which |
19 | | //! covers manual `cargo xtask inject-agent-token` invocations from |
20 | | //! the repo root. |
21 | | //! |
22 | | //! If the token file is missing the subcommand is a silent no-op |
23 | | //! (with an informational log line). Fine-grained PATs |
24 | | //! (`github_pat_...`) are recommended because they can be restricted |
25 | | //! to specific repositories and to a subset of repository permissions. |
26 | | //! Classic (`ghp_...`) and OAuth (`gho_...`) tokens are accepted to |
27 | | //! avoid hard-blocking contributors who only have those, but each |
28 | | //! triggers a warning log line since they cannot be scoped tightly |
29 | | //! enough to preserve the least-privilege property. Any other content |
30 | | //! is rejected so we never inject arbitrary text as a token. |
31 | | |
32 | | use std::path::{Path, PathBuf}; |
33 | | |
34 | | use anyhow::{bail, Context, Result}; |
35 | | |
36 | | /// Prefix for a fine-grained personal access token. This is the |
37 | | /// recommended token shape because it can be restricted to specific |
38 | | /// repositories and to a subset of repository permissions. |
39 | | const FINE_GRAINED_PREFIX: &str = "github_pat_"; |
40 | | |
41 | | /// Prefix for a classic personal access token. Accepted to avoid |
42 | | /// hard-blocking contributors who only have a classic token, but |
43 | | /// flagged with a warning since classic tokens cannot be scoped to |
44 | | /// specific repositories or to a subset of repository permissions. |
45 | | const CLASSIC_PREFIX: &str = "ghp_"; |
46 | | |
47 | | /// Prefix for an OAuth user-to-server token. Accepted with the same |
48 | | /// caveat as [`CLASSIC_PREFIX`]. |
49 | | const OAUTH_PREFIX: &str = "gho_"; |
50 | | |
51 | | /// Relative path inside the source checkout where the contributor |
52 | | /// stores their GitHub token. |
53 | | const TOKEN_FILE_REL_PATH: &str = ".paseo/gh-token"; |
54 | | |
55 | | /// Relative path inside the worktree where Claude Code reads local, |
56 | | /// uncommitted per-project settings. |
57 | | const SETTINGS_FILE_REL_PATH: &str = ".claude/settings.local.json"; |
58 | | |
59 | | /// All side-effecting operations performed by this subcommand. |
60 | | /// |
61 | | /// Implement with mocks in tests to achieve zero filesystem, |
62 | | /// environment, or process side-effects. |
63 | | pub trait InjectAgentTokenSystem { |
64 | | /// Look up an environment variable. |
65 | | /// |
66 | | /// # Arguments |
67 | | /// |
68 | | /// * `key` - Environment variable name. |
69 | | /// |
70 | | /// # Returns |
71 | | /// |
72 | | /// `Some(value)` when the variable is set and non-empty, |
73 | | /// `None` otherwise. |
74 | | fn env_var(&self, key: &str) -> Option<String>; |
75 | | |
76 | | /// Return the current working directory. |
77 | | /// |
78 | | /// # Errors |
79 | | /// |
80 | | /// Returns an error if the current directory cannot be |
81 | | /// determined. |
82 | | fn current_dir(&self) -> Result<PathBuf>; |
83 | | |
84 | | /// Read the token file at `path`. |
85 | | /// |
86 | | /// # Arguments |
87 | | /// |
88 | | /// * `path` - Absolute or worktree-relative path to the token |
89 | | /// file. |
90 | | /// |
91 | | /// # Returns |
92 | | /// |
93 | | /// `Ok(Some(contents))` when the file exists and is readable, |
94 | | /// `Ok(None)` when it does not exist (the subcommand treats |
95 | | /// this as a no-op). |
96 | | /// |
97 | | /// # Errors |
98 | | /// |
99 | | /// Returns an error for filesystem failures other than |
100 | | /// "not found" (for example, permission denied). |
101 | | fn read_token_file(&self, path: &Path) -> Result<Option<String>>; |
102 | | |
103 | | /// Write `contents` to the settings file at `path`, creating |
104 | | /// any missing parent directories. |
105 | | /// |
106 | | /// # Arguments |
107 | | /// |
108 | | /// * `path` - Target path for the settings file. |
109 | | /// * `contents` - Full file contents to write. |
110 | | /// |
111 | | /// # Errors |
112 | | /// |
113 | | /// Returns an error if directory creation or the write fails. |
114 | | fn write_settings(&self, path: &Path, contents: &str) -> Result<()>; |
115 | | |
116 | | /// Emit an informational or warning message to the user. |
117 | | /// |
118 | | /// # Arguments |
119 | | /// |
120 | | /// * `msg` - Message to display. |
121 | | fn log(&self, msg: &str); |
122 | | } |
123 | | |
124 | | /// Production implementation of [`InjectAgentTokenSystem`]. |
125 | | pub struct RealSystem; |
126 | | |
127 | | #[cfg_attr(coverage_nightly, coverage(off))] |
128 | | impl InjectAgentTokenSystem for RealSystem { |
129 | | fn env_var(&self, key: &str) -> Option<String> { |
130 | | std::env::var(key).ok().filter(|v| !v.is_empty()) |
131 | | } |
132 | | |
133 | | fn current_dir(&self) -> Result<PathBuf> { |
134 | | std::env::current_dir().context("failed to resolve current directory") |
135 | | } |
136 | | |
137 | | fn read_token_file(&self, path: &Path) -> Result<Option<String>> { |
138 | | match std::fs::read_to_string(path) { |
139 | | Ok(contents) => Ok(Some(contents)), |
140 | | Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), |
141 | | Err(err) => Err(err).with_context(|| format!("failed to read {}", path.display())), |
142 | | } |
143 | | } |
144 | | |
145 | | fn write_settings(&self, path: &Path, contents: &str) -> Result<()> { |
146 | | if let Some(parent) = path.parent() { |
147 | | std::fs::create_dir_all(parent) |
148 | | .with_context(|| format!("failed to create {}", parent.display()))?; |
149 | | } |
150 | | std::fs::write(path, contents) |
151 | | .with_context(|| format!("failed to write {}", path.display()))?; |
152 | | Ok(()) |
153 | | } |
154 | | |
155 | | fn log(&self, msg: &str) { |
156 | | println!("{msg}"); |
157 | | } |
158 | | } |
159 | | |
160 | | /// Build the JSON body written to `.claude/settings.local.json`. |
161 | | /// |
162 | | /// Caller-enforced invariant: `token` contains only bytes in |
163 | | /// `[A-Za-z0-9_]`. That alphabet has no characters that require JSON |
164 | | /// escaping, which is what lets this function skip a general-purpose |
165 | | /// JSON encoder without risking injection. The invariant is enforced |
166 | | /// by [`is_in_token_alphabet`] inside [`inject_agent_token`]. |
167 | | /// |
168 | | /// # Arguments |
169 | | /// |
170 | | /// * `token` - GitHub token, already validated and trimmed. |
171 | | /// |
172 | | /// # Returns |
173 | | /// |
174 | | /// A pretty-printed JSON document terminated with a newline. |
175 | 4 | fn build_settings_body(token: &str) -> String { |
176 | 4 | format!( |
177 | | "{{\n \"env\": {{\n \"GH_TOKEN\": \"{token}\",\n \"GH_HOST\": \"github.com\"\n }}\n}}\n" |
178 | | ) |
179 | 4 | } |
180 | | |
181 | | /// Return `true` when every byte of `token` is in the GitHub token |
182 | | /// alphabet `[A-Za-z0-9_]`. |
183 | | /// |
184 | | /// Enforcing this invariant is what lets [`build_settings_body`] |
185 | | /// embed the token directly into a JSON template without escaping - |
186 | | /// none of the characters in this alphabet need JSON escaping, so a |
187 | | /// token that passes this check cannot break out of its string |
188 | | /// literal nor inject additional keys. Fine-grained PATs, classic |
189 | | /// PATs, and OAuth tokens all share the same alphabet, so the same |
190 | | /// check applies to every accepted token shape. |
191 | | /// |
192 | | /// # Arguments |
193 | | /// |
194 | | /// * `token` - Trimmed token to validate. |
195 | | /// |
196 | | /// # Returns |
197 | | /// |
198 | | /// `true` when `token` is non-empty and contains only the allowed |
199 | | /// characters; `false` otherwise. |
200 | 5 | fn is_in_token_alphabet(token: &str) -> bool { |
201 | 5 | !token.is_empty() |
202 | 5 | && token |
203 | 5 | .bytes() |
204 | 123 | .all5 (|b| b.is_ascii_alphanumeric() || b == b'_'9 ) |
205 | 5 | } |
206 | | |
207 | | /// Recognized GitHub token shapes. |
208 | | #[derive(Clone, Copy)] |
209 | | enum TokenKind { |
210 | | FineGrained, |
211 | | Classic, |
212 | | OAuth, |
213 | | } |
214 | | |
215 | | impl TokenKind { |
216 | | /// Identify the token shape from its prefix. |
217 | | /// |
218 | | /// # Arguments |
219 | | /// |
220 | | /// * `token` - Trimmed token contents. |
221 | | /// |
222 | | /// # Returns |
223 | | /// |
224 | | /// `Some(kind)` when the token starts with a recognized prefix, |
225 | | /// `None` otherwise. |
226 | 6 | fn classify(token: &str) -> Option<Self> { |
227 | 6 | if token.starts_with(FINE_GRAINED_PREFIX) { |
228 | 3 | Some(Self::FineGrained) |
229 | 3 | } else if token.starts_with(CLASSIC_PREFIX) { |
230 | 1 | Some(Self::Classic) |
231 | 2 | } else if token.starts_with(OAUTH_PREFIX) { |
232 | 1 | Some(Self::OAuth) |
233 | | } else { |
234 | 1 | None |
235 | | } |
236 | 6 | } |
237 | | } |
238 | | |
239 | | /// Resolve the source checkout directory. |
240 | | /// |
241 | | /// Paseo passes `PASEO_SOURCE_CHECKOUT_PATH` into `worktree.setup` |
242 | | /// subprocesses. When the variable is missing - for example when the |
243 | | /// subcommand is invoked manually - fall back to the current |
244 | | /// directory so running it from the repo root behaves intuitively. |
245 | | /// |
246 | | /// # Arguments |
247 | | /// |
248 | | /// * `system` - Injected I/O provider. |
249 | | /// |
250 | | /// # Returns |
251 | | /// |
252 | | /// The source checkout path. |
253 | | /// |
254 | | /// # Errors |
255 | | /// |
256 | | /// Returns an error only when the fallback `current_dir` lookup |
257 | | /// fails. |
258 | 9 | fn resolve_source_checkout<S: InjectAgentTokenSystem>(system: &S) -> Result<PathBuf> { |
259 | 9 | if let Some(path8 ) = system.env_var("PASEO_SOURCE_CHECKOUT_PATH") { |
260 | 8 | return Ok(PathBuf::from(path)); |
261 | 1 | } |
262 | 1 | system.current_dir() |
263 | 9 | } |
264 | | |
265 | | /// Inject the contributor's GitHub token into the current worktree's |
266 | | /// Claude Code settings. |
267 | | /// |
268 | | /// The token is read from `<source-checkout>/.paseo/gh-token`. A |
269 | | /// missing token file is treated as an opt-out: the function logs a |
270 | | /// notice and returns `Ok(())` so worktree creation is not blocked |
271 | | /// for contributors who have not set a token up yet. Fine-grained |
272 | | /// PATs are written silently; classic and OAuth tokens are written |
273 | | /// but trigger a warning log line recommending fine-grained PATs. |
274 | | /// |
275 | | /// # Arguments |
276 | | /// |
277 | | /// * `system` - Injected I/O provider. |
278 | | /// |
279 | | /// # Returns |
280 | | /// |
281 | | /// `Ok(())` on success or when the token file is absent. |
282 | | /// |
283 | | /// # Errors |
284 | | /// |
285 | | /// Returns an error when a token file exists but does not start with |
286 | | /// one of the recognized prefixes ([`FINE_GRAINED_PREFIX`], |
287 | | /// [`CLASSIC_PREFIX`], [`OAUTH_PREFIX`]), when its trimmed contents |
288 | | /// fall outside the token alphabet (see [`is_in_token_alphabet`]), |
289 | | /// or when the settings file cannot be written. |
290 | 9 | pub fn inject_agent_token<S: InjectAgentTokenSystem>(system: &S) -> Result<()> { |
291 | 9 | let source = resolve_source_checkout(system)?0 ; |
292 | 9 | let token_file = source.join(TOKEN_FILE_REL_PATH); |
293 | | |
294 | 9 | let Some(raw7 ) = system.read_token_file(&token_file)?0 else { |
295 | 2 | system.log(&format!( |
296 | 2 | "INFO - paseo agent GitHub auth: no {} found; agents will use your existing gh login. See CONTRIBUTING.md.", |
297 | 2 | token_file.display() |
298 | 2 | )); |
299 | 2 | return Ok(()); |
300 | | }; |
301 | | |
302 | 7 | let token = raw.trim(); |
303 | 7 | if token.is_empty() { |
304 | 1 | bail!( |
305 | | "{} is empty; expected a GitHub token starting with `{}` (recommended), `{}`, or `{}`. See CONTRIBUTING.md.", |
306 | 1 | token_file.display(), |
307 | | FINE_GRAINED_PREFIX, |
308 | | CLASSIC_PREFIX, |
309 | | OAUTH_PREFIX, |
310 | | ); |
311 | 6 | } |
312 | 6 | let Some(kind5 ) = TokenKind::classify(token) else { |
313 | 1 | bail!( |
314 | | "{} must contain a GitHub token starting with `{}` (recommended), `{}`, or `{}`. See CONTRIBUTING.md.", |
315 | 1 | token_file.display(), |
316 | | FINE_GRAINED_PREFIX, |
317 | | CLASSIC_PREFIX, |
318 | | OAUTH_PREFIX, |
319 | | ); |
320 | | }; |
321 | 5 | if !is_in_token_alphabet(token) { |
322 | 1 | bail!( |
323 | | "{} contains characters outside the GitHub token alphabet ([A-Za-z0-9_]); refusing to embed it in settings. See CONTRIBUTING.md.", |
324 | 1 | token_file.display() |
325 | | ); |
326 | 4 | } |
327 | | |
328 | 4 | let cwd = system.current_dir()?0 ; |
329 | 4 | let settings_path = cwd.join(SETTINGS_FILE_REL_PATH); |
330 | 4 | let body = build_settings_body(token); |
331 | 4 | system.write_settings(&settings_path, &body)?0 ; |
332 | | |
333 | 4 | match kind { |
334 | 2 | TokenKind::FineGrained => { |
335 | 2 | system.log(&format!( |
336 | 2 | "INFO - paseo agent GitHub auth: wrote {} from {} (scoped PAT)", |
337 | 2 | settings_path.display(), |
338 | 2 | token_file.display() |
339 | 2 | )); |
340 | 2 | } |
341 | 1 | TokenKind::Classic => { |
342 | 1 | system.log(&format!( |
343 | 1 | "WARN - paseo agent GitHub auth: detected a classic token in {}; wrote {} but fine-grained PATs (prefix `{}`) are recommended because they can be restricted to specific repositories and permissions, while classic tokens cannot. See CONTRIBUTING.md.", |
344 | 1 | token_file.display(), |
345 | 1 | settings_path.display(), |
346 | 1 | FINE_GRAINED_PREFIX, |
347 | 1 | )); |
348 | 1 | } |
349 | 1 | TokenKind::OAuth => { |
350 | 1 | system.log(&format!( |
351 | 1 | "WARN - paseo agent GitHub auth: detected an OAuth token in {}; wrote {} but fine-grained PATs (prefix `{}`) are recommended because they can be restricted to specific repositories and permissions, while OAuth tokens cannot. See CONTRIBUTING.md.", |
352 | 1 | token_file.display(), |
353 | 1 | settings_path.display(), |
354 | 1 | FINE_GRAINED_PREFIX, |
355 | 1 | )); |
356 | 1 | } |
357 | | } |
358 | | |
359 | 4 | Ok(()) |
360 | 9 | } |
361 | | |
362 | | #[cfg(test)] |
363 | | #[path = "tests/test_inject_agent_token.rs"] |
364 | | mod tests; |